#common
from datetime import datetime, timedelta, date
import time
import pandas as pd
import numpy as np
import langid as lid
import re
#analytic lib
# import ab_calc
import math
import scipy as sc
# import pyexasol
#plotly
import plotly.express as px
import plotly.graph_objs as go
from plotly import tools
import plotly.offline as py
py.init_notebook_mode(connected=True)
#matplotlib
from matplotlib import pyplot as plt
from matplotlib import rc
import seaborn as sns
%matplotlib inline
Общее описание задачи
Есть данные по контенту, который создают пользователи. Каждая строка в датасете — единица контента. У каждой единицы контента есть некий score, который отражает его “плохость”.
Нужно сделать
Будет особенно круто, если поделишься исходными расчетами. Это может быть что угодно: гугл таблица, ноутбук в юпитере, проект на R/Python.
1) Хотим сделать среду Wakie чище. Люди иногда постят нежелательный контент, некоторые делают это регулярно. Хотим научиться определять таких людей и как-то с ними работать.
2) Для текстового контента плохость определяется моделью и, по факту, представляет собой вероятность того, что контент пошлый. Для текстового контента значение изменяется от 0 до 1. Где 1 — точно прошлый, 0 — точно не пошлый. Для звонков указаны значения 0,1,5. 0 — человек не получил оценку после звонка, 1 — получил дизлайк, 5 — получил лайк.
3) Хотим попробовать сегментацию, чтобы научиться выделять конкретные группы людей и в дальнейшем с ними работать.
4) Единицы контента можно связать только по user_id. Топики с комментами соединить не получится, потому что у последних нет родительского айди
Соответственно комментарий под топиком — коммент
Действительно, модель плохо работает не на англе, потому что она обучалась только на англе
В выборке топики, которые были в английском фиде (ленте). Если там есть не на англе фактически, это значит, что юзер принудительно пушнул топик не на англе в англ фид
Люди оценивают просто звонок. Примерно как оценивают поездку в такси: нравится, не нравится, все равно
data = pd.read_csv('wt_data.csv', parse_dates=['created']).iloc[:,1:]
data.head()
| created | content_id | content | quality | user_id | content_type | |
|---|---|---|---|---|---|---|
| 0 | 2021-06-03 00:01:49.209 | 60b81bed33bd2e0d0a26876d | What’s a secret you have? | 0.102579 | 60b81bda33bd2e0d0a2686a1 | topic |
| 1 | 2021-06-04 06:44:27.720 | 60b9cbcba8e8c52f6f224ea4 | any shit but abrahamic religions | 0.156906 | 60b81e6b5c002c655605235c | topic |
| 2 | 2021-06-04 05:27:05.247 | 60b9b9a96d0ef34d9207b512 | 10 layers of filters | 0.124180 | 60b81e6b5c002c655605235c | topic |
| 3 | 2021-06-04 05:05:08.699 | 60b9b4846d0ef34d920770ee | Zarathustra sounds like a Sanskrit name\n\nIt'... | 0.120621 | 60b81e6b5c002c655605235c | topic |
| 4 | 2021-06-03 20:52:53.296 | 60b9412533bd2e0c40b7f115 | why official religion of Malaysia is Islam ?\n... | 0.098455 | 60b81e6b5c002c655605235c | topic |
data.info(), data.nunique()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 368023 entries, 0 to 368022 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 created 368023 non-null datetime64[ns] 1 content_id 368023 non-null object 2 content 368023 non-null object 3 quality 368023 non-null float64 4 user_id 368023 non-null object 5 content_type 368023 non-null object dtypes: datetime64[ns](1), float64(1), object(4) memory usage: 16.8+ MB
(None, created 365140 content_id 364900 content 297445 quality 115875 user_id 4636 content_type 4 dtype: int64)
# типы контента
100*data.content_type.value_counts(normalize=True),\
data.content_type.value_counts()
(comment 58.462107 topic 23.110240 call 18.332006 mod_conf_porno 0.095646 Name: content_type, dtype: float64, comment 215154 topic 85051 call 67466 mod_conf_porno 352 Name: content_type, dtype: int64)
data.created.agg(['min', 'max'])
min 2021-06-03 00:01:49.209 max 2021-07-22 11:21:56.073 Name: created, dtype: datetime64[ns]
# поисследуем теперь юзеров
data.groupby('content_type').user_id.nunique().sort_values(ascending=False)
content_type topic 3703 call 2833 comment 2722 mod_conf_porno 152 Name: user_id, dtype: int64
user_to_content_df = data[data.content_type!='mod_conf_porno'][['user_id', 'content_type']].drop_duplicates()\
.sort_values(['user_id', 'content_type']).rename({'content_type':'content_types'}, axis=1)
100*user_to_content_df.groupby('user_id').content_types.sum()\
.value_counts(normalize=True)
callcommenttopic 33.843831 topic 18.075928 commenttopic 14.646247 calltopic 13.308887 call 9.900777 comment 6.169111 callcomment 4.055220 Name: content_types, dtype: float64
# насколько активны в разных категориях
activity_df = data[data.content_type!='mod_conf_porno']\
.merge(user_to_content_df.groupby('user_id').content_types.sum().reset_index(),
on='user_id')
(activity_df.groupby('content_types').user_id.count()/\
activity_df.groupby('content_types').user_id.nunique()).sort_values(ascending=False)
content_types callcommenttopic 184.123008 commenttopic 79.051546 callcomment 38.813830 calltopic 14.416532 call 12.912854 comment 5.681818 topic 1.625298 Name: user_id, dtype: float64
activity_df.groupby(['content_types', 'content_type']).user_id.count()/\
activity_df.groupby(['content_types', 'content_type']).user_id.nunique()
content_types content_type
call call 12.912854
callcomment call 25.207447
comment 13.606383
callcommenttopic call 31.878904
comment 110.274060
topic 41.970045
calltopic call 10.991896
topic 3.424635
comment comment 5.681818
commenttopic comment 55.892489
topic 23.159057
topic topic 1.625298
Name: user_id, dtype: float64
# распределение оценок
data.boxplot(column='quality',by='content_type', figsize=(8,5))
<AxesSubplot:title={'center':'quality'}, xlabel='content_type'>
data_no_calls = data[data.content_type!='call']
data_no_calls['content'] = data_no_calls['content'].str.lower()
data_calls = data[data.content_type=='call']
<ipython-input-13-c611228af9b3>:2: SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame. Try using .loc[row_indexer,col_indexer] = value instead See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
data_no_calls.boxplot(column='quality',by='content_type', figsize=(9,5))
<AxesSubplot:title={'center':'quality'}, xlabel='content_type'>
data_no_calls[(data_no_calls.content_type=='comment')].quality.value_counts(normalize=True).iloc[:5],\
data_no_calls[(data_no_calls.content_type=='topic')].quality.value_counts(normalize=True).iloc[:5],\
data_no_calls[(data_no_calls.content_type=='mod_conf_porno')].quality.value_counts(normalize=True).iloc[:5]
(0.070567 0.112315 0.064984 0.013270 0.052512 0.008385 0.050921 0.007213 0.057750 0.006544 Name: quality, dtype: float64, 0.070567 0.011134 0.954191 0.008454 0.065615 0.003316 0.106915 0.003163 0.080031 0.003139 Name: quality, dtype: float64, 0.070567 0.073864 0.771005 0.031250 0.141896 0.028409 0.377356 0.025568 0.945163 0.017045 Name: quality, dtype: float64)
px.histogram(data_no_calls[['content_type','quality']], color='content_type')
emoji_pattern = re.compile("["
u"\U0001F600-\U0001F64F" # emoticons
u"\U0001F300-\U0001F5FF" # symbols & pictographs
u"\U0001F680-\U0001F6FF" # transport & map symbols
u"\U0001F1E0-\U0001F1FF" # flags (iOS)
"]+", flags=re.UNICODE)
data_no_calls['is_eng'] = data_no_calls.content.apply(lambda x:1 if lid.classify(str(re.sub('[!@#$?:!/;]', '',
emoji_pattern.sub(r'', x))))[0]=='en' else 0)
<ipython-input-18-50ef27bfad17>:8: SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame. Try using .loc[row_indexer,col_indexer] = value instead See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
# боксплот на условно английской выборке
data_no_calls_eng = data_no_calls[data_no_calls.is_eng==1]
data_no_calls_eng.boxplot(column='quality',by='content_type', figsize=(9,5))
<AxesSubplot:title={'center':'quality'}, xlabel='content_type'>
data_no_calls_eng[data_no_calls_eng.content_type=='mod_conf_porno'].sort_values('quality').iloc[60:62]
| created | content_id | content | quality | user_id | content_type | is_eng | |
|---|---|---|---|---|---|---|---|
| 367903 | 2021-06-08 06:42:16.630 | 60bdaf416d0ef36ebd417dee | what should i do ?\nneck bite or liking pussy? | 0.501163 | 60bc57756d0ef36e19dfd2bd | mod_conf_porno | 1 |
| 367820 | 2021-06-21 13:47:05.506 | 60c8fb6733bd2e01f67cef6e | watch full video for sex chat with me: https:/... | 0.509791 | 60bfb8da07919a7af03c9564 | mod_conf_porno | 1 |
# посмотрим какие есть особенности по времени, может это что-то даст, а может нет
data_no_calls['created_hour'] = data_no_calls.created.apply(lambda x:str(x)[11:13])
<ipython-input-21-dac83a6d74e7>:2: SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame. Try using .loc[row_indexer,col_indexer] = value instead See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
px.bar(data_no_calls.groupby(['content_type','created_hour']).quality.count().reset_index().set_index('created_hour'),
barmode='group', color='content_type', title='Кол-во контента по часам')
px.bar(data_no_calls[data_no_calls.content_type!='mod_conf_porno'].groupby(['content_type','created_hour']).quality.median().reset_index().set_index('created_hour'),
barmode='group', color='content_type', title='Медианная "пошлость" контента по часам')
px.bar((data_no_calls[(data_no_calls.quality>0.5)
&(data_no_calls.content_type!='mod_conf_porno')]\
.groupby(['content_type','created_hour']).quality.count()/\
data_no_calls[(data_no_calls.content_type!='mod_conf_porno')]\
.groupby(['content_type','created_hour']).quality.count()).reset_index().set_index('created_hour'),
barmode='group', color='content_type', title='Доля нежелательного контента (>0.5) по часам')
px.bar((data_no_calls[(data_no_calls.is_eng==1)&(data_no_calls.quality>0.5)
&(data_no_calls.content_type!='mod_conf_porno')]\
.groupby(['content_type','created_hour']).quality.count()/\
data_no_calls[(data_no_calls.is_eng==1)&(data_no_calls.content_type!='mod_conf_porno')]\
.groupby(['content_type','created_hour']).quality.count()).reset_index().set_index('created_hour'),
barmode='group', color='content_type', title='Доля нежелательного контента (>0.5) по часам (eng)')
Это ничего не дало, но было интересно
# посмотрим долю плохого контента по разным типам юзеров. берем только комменты и топики
bad_content_thresh = 0.8
data_with_types_df = data_no_calls[data_no_calls.content_type!='mod_conf_porno'].merge(
user_to_content_df.groupby('user_id').content_types.sum().reset_index(), on='user_id')
bad_content_all_share_from_all = (data_with_types_df[data_with_types_df.quality>bad_content_thresh]\
.groupby(['content_type']).quality.count()/\
data_with_types_df[data_with_types_df.quality>bad_content_thresh].quality.count())
bad_content_share_from_all = (data_with_types_df[data_with_types_df.quality>bad_content_thresh]\
.groupby(['content_types']).quality.count()/\
data_with_types_df[data_with_types_df.quality>bad_content_thresh].quality.count())
bad_content_share_by_type_from_all = (data_with_types_df[data_with_types_df.quality>bad_content_thresh]\
.groupby(['content_types', 'content_type']).quality.count()/\
data_with_types_df[data_with_types_df.quality>bad_content_thresh].quality.count())
bad_content_share_from_group = (data_with_types_df[data_with_types_df.quality>bad_content_thresh]\
.groupby(['content_types']).quality.count()/\
data_with_types_df\
.groupby(['content_types']).quality.count())
bad_content_share_by_type_from_group = (data_with_types_df[data_with_types_df.quality>bad_content_thresh]\
.groupby(['content_types','content_type']).quality.count()/\
data_with_types_df\
.groupby(['content_types','content_type']).quality.count())
print("Доля плохого контента: {:.1%}".format(data_with_types_df[data_with_types_df.quality>bad_content_thresh].quality.count()/\
data_with_types_df.quality.count()))
Доля плохого контента: 2.3%
px.bar(bad_content_all_share_from_all, barmode='group',
title='Доля пошлого контента по типу контента', height=450, width=600)
px.bar(pd.concat([bad_content_share_from_all,
bad_content_share_from_group], axis=1,
keys=('share_from_all', 'share_from_group')), barmode='group',
title='Доля пошлого контента по типам юзеров', height=450)
px.bar(pd.concat([bad_content_share_by_type_from_all,
bad_content_share_by_type_from_group], axis=1,
keys=('share_from_all', 'share_from_group')).reset_index('content_type'), barmode='group',
title='Доля пошлого контента по типам юзеров и типам контента', height=450,facet_col='content_type')
# data_no_calls[(data_no_calls.content_type=='mod_conf_porno')&(data_no_calls.is_eng==1)].sort_values('quality').iloc[:60]
data_calls.content = data_calls.content.astype('float')
data_calls[data_calls.content<1000].boxplot(column='content',by='quality', figsize=(8,5))
px.histogram(data_calls[data_calls.content<1000][['content', 'quality']], color='quality', marginal='box')
/Users/a.sharonova/opt/anaconda3/lib/python3.8/site-packages/pandas/core/generic.py:5168: SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame. Try using .loc[row_indexer,col_indexer] = value instead See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
Будем использовать звонки для определения того, насколько человек хороший или плохой
data_with_types_df.is_eng.value_counts(normalize=True)
1 0.801006 0 0.198994 Name: is_eng, dtype: float64
Цель в том, чтоб определять пошлый контент и что-то с ним делать и с юзерами, которые его постят
Модель работает изначально только для английского текста, так что мы можем работать только с 80%-90% контента (определитель языка не очень точный).
Что можно с этим сделать:
Как использовать модель:
Интресных особенностей много, но отмечу то, что пригодится для дальнейшего ресерча:
thresh = 0.5
data_with_types_df['is_bad_content'] = np.where(data_with_types_df.quality>thresh,1,0)
data_with_types_df['is_bad_comment'] = np.where((data_with_types_df.quality>thresh)&
(data_with_types_df.content_type=='comment'),1,0)
data_with_types_df['is_bad_topic'] = np.where((data_with_types_df.quality>thresh)&
(data_with_types_df.content_type=='topic'),1,0)
bad_share_user_df = data_with_types_df.groupby('user_id').is_bad_content.mean().reset_index()\
.rename({'is_bad_content':'bad_content_share'}, axis=1).fillna(0)
bad_cnt_user_df = data_with_types_df.groupby('user_id').is_bad_content.sum().reset_index()\
.rename({'is_bad_content':'bad_content_cnt'}, axis=1).fillna(0)
bad_comment_share_user_df = data_with_types_df.groupby('user_id').is_bad_comment.mean().reset_index()\
.rename({'is_bad_comment':'bad_comment_share'}, axis=1).fillna(0)
bad_comment_cnt_user_df = data_with_types_df.groupby('user_id').is_bad_comment.sum().reset_index()\
.rename({'is_bad_comment':'bad_comment_cnt'}, axis=1).fillna(0)
bad_topic_share_user_df = data_with_types_df.groupby('user_id').is_bad_topic.mean().reset_index()\
.rename({'is_bad_topic':'bad_topic_share'}, axis=1).fillna(0)
bad_topic_cnt_user_df = data_with_types_df.groupby('user_id').is_bad_topic.sum().reset_index()\
.rename({'is_bad_topic':'bad_topic_cnt'}, axis=1).fillna(0)
content_user_df = data_with_types_df[['user_id', 'content_types']].drop_duplicates()
call_rating_df = data_calls[data_calls.quality.isin([1,5])].groupby('user_id').quality.mean().reset_index()\
.rename({'quality':'call_rating'}, axis=1)
call_rating_cnt_df = data_calls[data_calls.quality.isin([1,5])].groupby('user_id').quality.count().reset_index()\
.rename({'quality':'call_rating_cnt'}, axis=1)
user_df = content_user_df.merge(bad_share_user_df,on='user_id', how='left')\
.merge(bad_comment_share_user_df,on='user_id', how='left')\
.merge(bad_topic_share_user_df,on='user_id', how='left')\
.merge(bad_cnt_user_df,on='user_id', how='left')\
.merge(bad_comment_cnt_user_df,on='user_id', how='left')\
.merge(bad_topic_cnt_user_df,on='user_id', how='left')\
.merge(call_rating_df,on='user_id', how='left')\
.merge(call_rating_cnt_df,on='user_id', how='left').fillna(0)
user_df['topic_share'] = user_df.bad_topic_cnt/user_df.bad_content_cnt
bad_user_df = user_df[(user_df.bad_content_share>0)]
print('Доля юзеров, которые постят плохой контент: {:.1%}'.format(bad_user_df.user_id.nunique()/data.user_id.nunique()))
print('Доля юзеров, которые постят плохой контент больше двух раз: {:.1%}'.format(
bad_user_df[bad_user_df.bad_content_cnt>=3].user_id.nunique()/data.user_id.nunique()))
print('--- Доля от всех, кто постит плохой контент: {:.1%}'.format(
bad_user_df[bad_user_df.bad_content_cnt>=3].user_id.nunique()/bad_user_df.user_id.nunique()))
Доля юзеров, которые постят плохой контент: 28.1% Доля юзеров, которые постят плохой контент больше двух раз: 15.2% --- Доля от всех, кто постит плохой контент: 53.9%
Будем считать, что до трех постов мы ничего не делаем, потому что это может быть случайно, или можно отправлять легкие предупреждения
Кажется, что делить юзеров на средне-пошлый и очень пошлый контент - излишнее усложнение, так что будем работать с одной метрикой
bad_user_more2_df = bad_user_df[bad_user_df.bad_content_cnt>=3]
Следующая группа людей - те, кто запостил меньше 10% пошлого контента. В этой группе модель часто ошибается в пошлости. С ними мы не будем делать ничего, потому что вероятнее всего это безобидные разговоры про секс
bad_user_more2_less10_df = bad_user_more2_df[bad_user_more2_df.bad_content_share<0.1]
print('Доля юзеров, которые постят меньше 10% плохого контента (и больше 2х единиц): {:.1%}'.format(
bad_user_more2_less10_df.user_id.nunique()/data.user_id.nunique()))
print('--- Доля от всех, кто постит плохой контент: {:.1%}'.format(
bad_user_more2_less10_df.user_id.nunique()/bad_user_df.user_id.nunique()))
Доля юзеров, которые постят меньше 10% плохого контента (и больше 2х единиц): 8.8% --- Доля от всех, кто постит плохой контент: 31.3%
bad_user_more2_without_less10_df = bad_user_more2_df[bad_user_more2_df.bad_content_share>=0.1]
bad_user_more2_without_less10_df.user_id.nunique()/bad_user_df.user_id.nunique()
0.22622699386503067
У нас остается 22%, с которыми уже надо работать
Следующий этап - это взять людей, у которых доля выше 40%. По данным кажется, что это юзеры, которые постят самую дичь и засоряют контент. С ними как раз надо выработать стратегию работы.
print('Доля юзеров, которые постят больше 40% плохого контента (и больше 2х единиц): {:.1%}'.format(
bad_user_more2_without_less10_df[(bad_user_more2_without_less10_df.bad_content_share>=0.4)].user_id.nunique()\
/data.user_id.nunique()))
print('--- Доля от всех, кто постит плохой контент: {:.1%}'.format(
bad_user_more2_without_less10_df[bad_user_more2_without_less10_df.bad_content_share>=0.4].user_id.nunique()/\
bad_user_df.user_id.nunique()))
Доля юзеров, которые постят больше 40% плохого контента (и больше 2х единиц): 2.0% --- Доля от всех, кто постит плохой контент: 7.1%
bad_user_more2_less40_df = bad_user_more2_without_less10_df[bad_user_more2_without_less10_df.bad_content_share<0.4]
bad_user_more2_less40_df.user_id.nunique()/bad_user_df.user_id.nunique()
0.1549079754601227
Осталось 15% юзеров, которые находятся в пограничном состянии, так как контент может быть не таким уж плохим. Здесь уже будем определять плохость по рейтингу. Будем брать рейтинги от 2х оценок, чтоб было хоть какое-то альтернативное мнение:)
bad_user_df.user_id.nunique()
1304
bad_user_more2_less40_bad_call_rating_df = bad_user_more2_less40_df[(bad_user_more2_less40_df.call_rating_cnt>=2)&
((bad_user_more2_less40_df.call_rating<3))]
У нас появилась группа людей с плохим рейтингом по звонкам и долей 10-40%
print('Доля юзеров, которые постят меньше 40% плохого контента с плохим рейтингом: {:.1%}'.format(
bad_user_more2_less40_bad_call_rating_df.user_id.nunique()\
/data.user_id.nunique()))
print('--- Доля от всех, кто постит плохой контент: {:.1%}'.format(
bad_user_more2_less40_bad_call_rating_df.user_id.nunique()/\
bad_user_df.user_id.nunique()))
Доля юзеров, которые постят меньше 40% плохого контента с плохим рейтингом: 0.7% --- Доля от всех, кто постит плохой контент: 2.5%
bad_user_more2_less40_good_rating_df = bad_user_more2_less40_df[(bad_user_more2_less40_df.call_rating_cnt<2)]
bad_user_more2_less40_good_rating_df.user_id.nunique()/bad_user_df.user_id.nunique()
0.0736196319018405
Последний вариант будет банить тех, кто создал больше 10 единиц пошлого контента за месяц и больше половины это топики
print('Доля юзеров, которые постят меньше 40% плохого контента с плохим рейтингом: {:.1%}'.format(
bad_user_more2_less40_good_rating_df[(bad_user_more2_less40_good_rating_df.bad_topic_cnt>=10)].user_id.nunique()\
/data.user_id.nunique()))
print('--- Доля от всех, кто постит плохой контент: {:.1%}'.format(
bad_user_more2_less40_good_rating_df[(bad_user_more2_less40_good_rating_df.bad_topic_cnt>=10)].user_id.nunique()/\
bad_user_df.user_id.nunique()))
Доля юзеров, которые постят меньше 40% плохого контента с плохим рейтингом: 0.6% --- Доля от всех, кто постит плохой контент: 2.1%
other_bad_df = bad_user_more2_less40_good_rating_df[(bad_user_more2_less40_good_rating_df.bad_topic_cnt<10)]
other_bad_df.user_id.nunique()/bad_user_df.user_id.nunique()
0.05291411042944785
Модерация оставшихся юзеров должна проходить более естественно - через жалобы
Если мы ориентируемся на пользователя и не хотим потерять часть аудитории, которая просто хочет поговорить о сексе, то необходимо разработать такой подход, где мы теоретически будем удалять действительно раздражающих пользователей и не задевать остальных. При разделении я попыталась выделить ту аудиторию, с которой надо более мягко работать.
В итоге получились такие сегменты:
Итого у нас 4 группы:
Можно подумать как построить модель, которая будет определять никому не нужный пошлый контент, но кажется это того не стоит.
Очевидно, стоит разделить работу с контентом и работу с пользователями. Для контента должны быть общие правила, которые все понимают.
Работа с контентом
Работа с пользователями
У нас теперь есть группы. Как можно работать с каждой из них: